import 'dart:async';
import 'dart:convert';
import 'dart:math';

import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';

import 'base_downloader.dart';
import 'database.dart';
import 'localstore/localstore.dart';
import 'models.dart';
import 'permissions.dart';
import 'persistent_storage.dart';
import 'queue/task_queue.dart';
import 'task.dart';
import 'uri/uri_utils.dart';
import 'web_downloader.dart'
    if (dart.library.io) 'desktop/desktop_downloader.dart';

/// Provides access to all functions of the plugin in a single place.
interface class FileDownloader {
  static FileDownloader? _singleton;

  /// If no group is specified the default group name will be used
  static const defaultGroup = 'default';

  /// Special group name for tasks that download a chunk, as part of a
  /// [ParallelDownloadTask]
  static String get chunkGroup => BaseDownloader.chunkGroup;

  /// Database where tracked tasks are stored.
  ///
  /// Activate tracking by calling [trackTasks], and access the records in the
  /// database via this [database] object.
  late final Database database;

  /// Permissions authorization interface
  ///
  /// Use [Permissions.status] to get the authorization status for a permission
  /// Use [Permissions.request] to request a permission
  /// Use [Permissions.shouldShowRationale] to determine if an educational
  /// rationale for this permission should be shown
  Permissions get permissions => _downloader.permissionsService;

  /// Platform-specific implementation of the downloader itself
  late final BaseDownloader _downloader;

  /// Accesses utilities for working with URIs
  UriUtils? _uri;

  /// Do not use: for testing only
  @visibleForTesting
  BaseDownloader get downloaderForTesting => _downloader;

  factory FileDownloader({PersistentStorage? persistentStorage}) {
    assert(
        _singleton == null || persistentStorage == null,
        'You can only supply a persistentStorage on the very first call to '
        'FileDownloader()');
    _singleton ??= FileDownloader._internal(
        persistentStorage ?? LocalStorePersistentStorage());
    return _singleton!;
  }

  FileDownloader._internal(PersistentStorage persistentStorage) {
    database = Database(persistentStorage);
    _downloader = BaseDownloader.instance(persistentStorage, database);
  }

  /// True when initialization is complete and downloader ready for use
  Future<bool> get ready => _downloader.ready;

  /// Stream of [TaskUpdate] updates for downloads that do
  /// not have a registered callback
  Stream<TaskUpdate> get updates => _downloader.updates.stream;

  /// Accesses utilities for working with URIs. URIs make working with file pickers and
  /// shared storage easier, as they abstract permissions and are coherent
  /// across platforms.
  UriUtils get uri {
    _uri ??= UriUtils.withDownloader(_downloader);
    return _uri!;
  }

  /// Configures the downloader
  ///
  /// Configuration is either a single configItem or a list of configItems.
  /// Each configItem is a (String, dynamic) where the String is the config
  /// type and 'dynamic' can be any appropriate parameter, including another Record.
  /// [globalConfig] is routed to every platform, whereas the platform specific
  /// ones only get routed to that platform, after the global configs have
  /// completed.
  /// If a config type appears more than once, they will all be executed in order,
  /// with [globalConfig] executed before the platform-specific config.
  ///
  /// Returns a list of (String, String) which is the config type and a response
  /// which is empty if OK, 'not implemented' if the item could not be recognized and
  /// processed, or may contain other error/warning information
  ///
  /// Please see [CONFIG.md](https://github.com/781flyingdutchman/background_downloader/blob/main/CONFIG.md)
  /// for more information
  Future<List<(String, String)>> configure(
          {dynamic globalConfig,
          dynamic androidConfig,
          dynamic iOSConfig,
          dynamic desktopConfig}) =>
      _downloader.configure(
          globalConfig: globalConfig,
          androidConfig: androidConfig,
          iOSConfig: iOSConfig,
          desktopConfig: desktopConfig);

  /// Register status or progress callbacks to monitor download progress, and
  /// [TaskNotificationTapCallback] to respond to user tapping a notification.
  ///
  /// Status callbacks are called only when the state changes, while
  /// progress callbacks are called to inform of intermediate progress.
  ///
  /// Note that callbacks will be called based on a task's [updates]
  /// property, which defaults to status change callbacks only. To also get
  /// progress updates make sure to register a [TaskProgressCallback] and
  /// set the task's [updates] property to [Updates.progress] or
  /// [Updates.statusAndProgress].
  ///
  /// For notification callbacks, make sure your AndroidManifest includes
  /// android:launchMode="singleTask" to ensure proper behavior when a
  /// notification is tapped.
  ///
  /// Different callbacks can be set for different groups, and the group
  /// can be passed on with the [Task] to ensure the
  /// appropriate callbacks are called for that group.
  /// For the `taskNotificationTapCallback` callback, the `defaultGroup` callback
  /// is used when calling 'convenience' functions like `FileDownloader().download`
  ///
  /// The call returns the [FileDownloader] to make chaining easier
  FileDownloader registerCallbacks(
      {String group = defaultGroup,
      TaskStatusCallback? taskStatusCallback,
      TaskProgressCallback? taskProgressCallback,
      TaskNotificationTapCallback? taskNotificationTapCallback}) {
    assert(
        taskStatusCallback != null ||
            taskProgressCallback != null ||
            taskNotificationTapCallback != null,
        'Must provide at least one callback');
    if (taskStatusCallback != null) {
      _downloader.groupStatusCallbacks[group] = taskStatusCallback;
    }
    if (taskProgressCallback != null) {
      _downloader.groupProgressCallbacks[group] = taskProgressCallback;
    }
    if (taskNotificationTapCallback != null) {
      _downloader.groupNotificationTapCallbacks[group] =
          taskNotificationTapCallback;
    }
    return this;
  }

  /// Unregister a previously registered [TaskStatusCallback], [TaskProgressCallback]
  /// or [TaskNotificationTapCallback].
  ///
  /// [group] defaults to the [FileDownloader.defaultGroup]
  /// If [callback] is null, all callbacks for the [group] are unregistered
  FileDownloader unregisterCallbacks(
      {String group = defaultGroup, Function? callback}) {
    if (callback != null) {
      // remove specific callback
      if (_downloader.groupStatusCallbacks[group] == callback) {
        _downloader.groupStatusCallbacks.remove(group);
      }
      if (_downloader.groupProgressCallbacks[group] == callback) {
        _downloader.groupProgressCallbacks.remove(group);
      }
      if (_downloader.groupNotificationTapCallbacks[group] == callback) {
        _downloader.groupNotificationTapCallbacks.remove(group);
      }
    } else {
      // remove all callbacks related to group
      _downloader.groupStatusCallbacks.remove(group);
      _downloader.groupProgressCallbacks.remove(group);
      _downloader.groupNotificationTapCallbacks.remove(group);
    }
    return this;
  }

  /// Adds the [taskQueue] to this downloader
  ///
  /// Every [TaskQueue] will receive [TaskQueue.taskFinished] for
  /// every task that has reached a final state
  void addTaskQueue(TaskQueue taskQueue) =>
      _downloader.taskQueues.add(taskQueue);

  /// Removes [taskQueue] and return true if successful
  bool removeTaskQueue(TaskQueue taskQueue) =>
      _downloader.taskQueues.remove(taskQueue);

  /// List of connected [TaskQueue]s
  List<TaskQueue> get taskQueues => _downloader.taskQueues;

  /// Enqueue a new [Task]
  ///
  /// Returns true if successfully enqueued. A new task will also generate
  /// a [TaskStatus.enqueued] update to the registered callback,
  /// if requested by its [updates] property
  ///
  /// Use [enqueue] instead of the convenience functions (like
  /// [download] and [upload]) if:
  /// - your download/upload is likely to take long and may require
  ///   running in the background
  /// - you want to monitor tasks centrally, via a listener
  /// - you want more detailed progress information
  ///   (e.g. file size, network speed, time remaining)
  Future<bool> enqueue(Task task) => _downloader.enqueue(task);

  /// Enqueues a list of tasks and returns a list of booleans indicating whether
  /// each task was successfully enqueued
  ///
  /// See [enqueue] for details
  Future<List<bool>> enqueueAll(Iterable<Task> tasks) =>
      _downloader.enqueueAll(tasks);

  /// Download a file and return the final [TaskStatusUpdate]
  ///
  /// Different from [enqueue], this method returns a [Future] that completes
  /// when the file has been downloaded, or an error has occurred.
  /// While it uses the same download mechanism as [enqueue],
  /// and will execute the download also when
  /// the app moves to the background, it is meant for downloads that are
  /// awaited while the app is in the foreground.
  ///
  /// Optional callbacks for status and progress updates may be
  /// added. These function only take a [TaskStatus] or [double] argument as
  /// the task they refer to is expected to be captured in the closure for
  /// this call.
  /// For example `Downloader.download(task, onStatus: (status) =>`
  /// `print('Status for ${task.taskId} is $status);`
  ///
  /// An optional callback [onElapsedTime] will be called at regular intervals
  /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a
  /// single argument that is the elapsed time since the call to [download].
  /// This can be used to trigger UI warnings (e.g. 'this is taking rather long')
  /// or to cancel the task if it does not complete within a desired time.
  /// For performance reasons the [elapsedTimeInterval] should not be set to
  /// a value less than one second.
  /// The [onElapsedTime] callback should not be used to indicate progress. For
  /// that, use the [onProgress] callback.
  ///
  /// Use [enqueue] instead of [download] if:
  /// - your download/upload is likely to take long and may require
  ///   running in the background
  /// - you want to monitor tasks centrally, via a listener
  /// - you want more detailed progress information
  ///   (e.g. file size, network speed, time remaining)
  Future<TaskStatusUpdate> download(DownloadTask task,
          {void Function(TaskStatus)? onStatus,
          void Function(double)? onProgress,
          void Function(Duration)? onElapsedTime,
          Duration? elapsedTimeInterval}) =>
      _downloader.enqueueAndAwait(task,
          onStatus: onStatus,
          onProgress: onProgress,
          onElapsedTime: onElapsedTime,
          elapsedTimeInterval: elapsedTimeInterval);

  /// Upload a file and return the final [TaskStatusUpdate]
  ///
  /// Different from [enqueue], this method returns a [Future] that completes
  /// when the file has been uploaded, or an error has occurred.
  /// While it uses the same upload mechanism as [enqueue],
  /// and will execute the upload also when
  /// the app moves to the background, it is meant for uploads that are
  /// awaited while the app is in the foreground.
  ///
  /// Optional callbacks for status and progress updates may be
  /// added. These function only take a [TaskStatus] or [double] argument as
  /// the task they refer to is expected to be captured in the closure for
  /// this call.
  /// For example `Downloader.upload(task, onStatus: (status) =>`
  /// `print('Status for ${task.taskId} is $status);`
  ///
  /// An optional callback [onElapsedTime] will be called at regular intervals
  /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a
  /// single argument that is the elapsed time since the call to [upload].
  /// This can be used to trigger UI warnings (e.g. 'this is taking rather long')
  /// or to cancel the task if it does not complete within a desired time.
  /// For performance reasons the [elapsedTimeInterval] should not be set to
  /// a value less than one second.
  /// The [onElapsedTime] callback should not be used to indicate progress. For
  /// that, use the [onProgress] callback.
  ///
  /// Note that the task's [group] is ignored and will be replaced with an
  /// internal group name 'await' to track status
  ///
  /// Use [enqueue] instead of [upload] if:
  /// - your download/upload is likely to take long and may require
  ///   running in the background
  /// - you want to monitor tasks centrally, via a listener
  /// - you want more detailed progress information
  ///   (e.g. file size, network speed, time remaining)
  Future<TaskStatusUpdate> upload(UploadTask task,
          {void Function(TaskStatus)? onStatus,
          void Function(double)? onProgress,
          void Function(Duration)? onElapsedTime,
          Duration? elapsedTimeInterval}) =>
      _downloader.enqueueAndAwait(task,
          onStatus: onStatus,
          onProgress: onProgress,
          onElapsedTime: onElapsedTime,
          elapsedTimeInterval: elapsedTimeInterval);

  /// Transmit data in the [DataTask] and receive the response
  ///
  /// Different from [enqueue], this method returns a [Future] that completes
  /// when the [DataTask] has completed, or an error has occurred.
  /// While it uses the same mechanism as [enqueue],
  /// and will execute the task also when
  /// the app moves to the background, it is meant for data tasks that are
  /// awaited while the app is in the foreground.
  ///
  /// [onStatus] is an optional callback for status updates
  ///
  /// An optional callback [onElapsedTime] will be called at regular intervals
  /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a
  /// single argument that is the elapsed time since the call to [transmit].
  /// This can be used to trigger UI warnings (e.g. 'this is taking rather long')
  /// For performance reasons the [elapsedTimeInterval] should not be set to
  /// a value less than one second.
  Future<TaskStatusUpdate> transmit(DataTask task,
          {void Function(TaskStatus)? onStatus,
          void Function(Duration)? onElapsedTime,
          Duration? elapsedTimeInterval}) =>
      _downloader.enqueueAndAwait(task,
          onStatus: onStatus,
          onElapsedTime: onElapsedTime,
          elapsedTimeInterval: elapsedTimeInterval);

  /// Enqueues a list of files to download and returns when all downloads
  /// have finished (successfully or otherwise). The returned value is a
  /// [Batch] object that contains the original [tasks], the
  /// [results] and convenience getters to filter successful and failed results.
  ///
  /// If an optional [batchProgressCallback] function is provided, it will be
  /// called upon completion (successfully or otherwise) of each task in the
  /// batch, with two parameters: the number of succeeded and the number of
  /// failed tasks. The callback can be used, for instance, to show a progress
  /// indicator for the batch, where
  ///    double percent_complete = (succeeded + failed) / tasks.length
  ///
  /// To also monitor status and/or progress for each task in the batch, provide
  /// a [taskStatusCallback] and/or [taskProgressCallback], which will be used
  /// for each task in the batch.
  ///
  /// An optional callback [onElapsedTime] will be called at regular intervals
  /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a
  /// single argument that is the elapsed time since the call to [downloadBatch].
  /// This can be used to trigger UI warnings (e.g. 'this is taking rather long')
  /// or to cancel the task if it does not complete within a desired time.
  /// For performance reasons the [elapsedTimeInterval] should not be set to
  /// a value less than one second.
  /// The [onElapsedTime] callback should not be used to indicate progress.
  ///
  /// [tasks] cannot be an empty list
  Future<Batch> downloadBatch(final List<DownloadTask> tasks,
          {BatchProgressCallback? batchProgressCallback,
          TaskStatusCallback? taskStatusCallback,
          TaskProgressCallback? taskProgressCallback,
          void Function(Duration)? onElapsedTime,
          Duration? elapsedTimeInterval}) =>
      _downloader.enqueueAndAwaitBatch(tasks,
          batchProgressCallback: batchProgressCallback,
          taskStatusCallback: taskStatusCallback,
          taskProgressCallback: taskProgressCallback,
          onElapsedTime: onElapsedTime,
          elapsedTimeInterval: elapsedTimeInterval);

  /// Enqueues a list of files to upload and returns when all uploads
  /// have finished (successfully or otherwise). The returned value is a
  /// [Batch] object that contains the original [tasks], the
  /// [results] and convenience getters to filter successful and failed results.
  ///
  /// If an optional [batchProgressCallback] function is provided, it will be
  /// called upon completion (successfully or otherwise) of each task in the
  /// batch, with two parameters: the number of succeeded and the number of
  /// failed tasks. The callback can be used, for instance, to show a progress
  /// indicator for the batch, where
  ///    double percent_complete = (succeeded + failed) / tasks.length
  ///
  /// To also monitor status and/or progress for each task in the batch, provide
  /// a [taskStatusCallback] and/or [taskProgressCallback], which will be used
  /// for each task in the batch.
  ///
  /// An optional callback [onElapsedTime] will be called at regular intervals
  /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a
  /// single argument that is the elapsed time since the call to [uploadBatch].
  /// This can be used to trigger UI warnings (e.g. 'this is taking rather long')
  /// or to cancel the task if it does not complete within a desired time.
  /// For performance reasons the [elapsedTimeInterval] should not be set to
  /// a value less than one second.
  /// The [onElapsedTime] callback should not be used to indicate progress.
  ///
  /// [tasks] cannot be an empty list
  Future<Batch> uploadBatch(final List<UploadTask> tasks,
          {BatchProgressCallback? batchProgressCallback,
          TaskStatusCallback? taskStatusCallback,
          TaskProgressCallback? taskProgressCallback,
          void Function(Duration)? onElapsedTime,
          Duration? elapsedTimeInterval}) =>
      _downloader.enqueueAndAwaitBatch(tasks,
          batchProgressCallback: batchProgressCallback,
          taskStatusCallback: taskStatusCallback,
          taskProgressCallback: taskProgressCallback,
          onElapsedTime: onElapsedTime,
          elapsedTimeInterval: elapsedTimeInterval);

  /// Resets the downloader by cancelling all ongoing tasks within
  /// the provided [group]
  ///
  /// Returns the number of tasks cancelled. Every canceled task wil emit a
  /// [TaskStatus.canceled] update to the registered callback, if
  /// requested
  ///
  /// This method acts on a [group] of tasks. If omitted, the [defaultGroup]
  /// is used, which is the group used when you [enqueue] a task
  Future<int> reset({String group = defaultGroup}) => _downloader.reset(group);

  /// Returns a list of taskIds of all tasks currently active in this [group]
  ///
  /// Active means enqueued or running, and if [includeTasksWaitingToRetry] is
  /// true also tasks that are waiting to be retried
  ///
  /// This method acts on a [group] of tasks. If omitted, the [defaultGroup]
  /// is used, which is the group used when you [enqueue] a task
  /// To get all tasks regardless of group, set [allGroups] to true as the
  /// only parameter
  Future<List<String>> allTaskIds(
          {String group = defaultGroup,
          bool includeTasksWaitingToRetry = true,
          allGroups = false}) async =>
      (await allTasks(
              group: group,
              includeTasksWaitingToRetry: includeTasksWaitingToRetry,
              allGroups: allGroups))
          .map((task) => task.taskId)
          .toList();

  /// Returns a list of all tasks currently active in this [group]
  ///
  /// Active means enqueued or running, and if [includeTasksWaitingToRetry] is
  /// true also tasks that are waiting to be retried
  ///
  /// This method acts on a [group] of tasks. If omitted, the [defaultGroup]
  /// is used, which is the group used when you [enqueue] a task.
  /// To get all tasks regardless of group, set [allGroups] to true as the
  /// only parameter
  Future<List<Task>> allTasks(
          {String group = defaultGroup,
          bool includeTasksWaitingToRetry = true,
          bool allGroups = false}) =>
      _downloader.allTasks(group, includeTasksWaitingToRetry, allGroups);

  /// Returns true if tasks in this [group] are finished
  ///
  /// Finished means "not active", i.e. no tasks are enqueued or running,
  /// and if [includeTasksWaitingToRetry] is true (the default), no tasks are
  /// waiting to be retried.
  /// Finished does not mean that all tasks completed successfully.
  ///
  /// This method acts on a [group] of tasks. If omitted, the [defaultGroup]
  /// is used, which is the group used when you [enqueue] a task.
  ///
  /// If an [ignoreTask] is provided, it will be excluded from the test. This
  /// allows you to test for [tasksFinished] within the status update callback
  /// for a task that just finished. In that situation, that task may still
  /// be returned by the platform as 'active', but you already know it is not.
  /// Calling [tasksFinished] while passing that just-finished task will ensure
  /// a proper test in that situation.
  Future<bool> tasksFinished(
      {String group = defaultGroup,
      bool includeTasksWaitingToRetry = true,
      String? ignoreTaskId}) async {
    final tasksInProgress = await allTasks(
        group: group, includeTasksWaitingToRetry: includeTasksWaitingToRetry);
    if (ignoreTaskId != null) {
      tasksInProgress.removeWhere((task) => task.taskId == ignoreTaskId);
    }
    return tasksInProgress.isEmpty;
  }

  /// Cancel all tasks matching the taskIds in the list
  ///
  /// Every canceled task wil emit a [TaskStatus.canceled] update to
  /// the registered callback, if requested
  Future<bool> cancelTasksWithIds(Iterable<String> taskIds) =>
      _downloader.cancelTasksWithIds(taskIds);

  /// Cancel this task
  ///
  /// The task will emit a [TaskStatus.canceled] update to
  /// the registered callback, if requested
  Future<bool> cancelTaskWithId(String taskId) => cancelTasksWithIds([taskId]);

  /// Cancel this task
  ///
  /// The task will emit a [TaskStatus.canceled] update to
  /// the registered callback, if requested
  Future<bool> cancel(Task task) => cancelTasksWithIds([task.taskId]);

  /// Cancels all tasks, or those in [tasks], or all tasks in group [group]
  ///
  /// Returns true if all cancellations were successful
  Future<bool> cancelAll({Iterable<Task>? tasks, String? group}) =>
      _downloader.cancelAll(tasks: tasks, group: group);

  /// Return [Task] for the given [taskId], or null
  /// if not found.
  ///
  /// Only running tasks are guaranteed to be returned, but returning a task
  /// does not guarantee that the task is still running. To keep track of
  /// the status of tasks, use a [TaskStatusCallback]
  Future<Task?> taskForId(String taskId) => _downloader.taskForId(taskId);

  /// Convenience start method for using the database. Must be called AFTER
  /// registering update callbacks or listener.
  ///
  /// Calls in order:
  /// [trackTasks] to start database tracking, with [markDownloadedComplete] to
  ///     ensure fully downloaded tasks are marked as complete in the database
  /// [resumeFromBackground] to fetch status and progress updates that may have
  ///     happened while the app was suspended
  /// [rescheduleKilledTasks] (after a 5 second delay) to ensure tasks that were
  ///     killed by the user are rescheduled
  ///
  /// [doTrackTasks] and [doRescheduleKilledTasks] can be set to false to skip
  /// that step.  [resumeFromBackground] is always called.
  Future<void> start(
      {bool doTrackTasks = true,
      bool markDownloadedComplete = true,
      bool doRescheduleKilledTasks = true}) async {
    if (doTrackTasks) {
      await FileDownloader()
          .trackTasks(markDownloadedComplete: markDownloadedComplete);
      if (doRescheduleKilledTasks) {
        Timer(const Duration(seconds: 5),
            () => FileDownloader().rescheduleKilledTasks());
      }
    }
    await FileDownloader().resumeFromBackground();
  }

  /// Activate tracking for tasks in this [group]
  ///
  /// All subsequent tasks in this group will be recorded in persistent storage.
  /// Use the [FileDownloader.database] to get or remove [TaskRecord] objects,
  /// which contain a [Task], its [TaskStatus] and a [double] for progress.
  ///
  /// If [markDownloadedComplete] is true (default) then all tasks in the
  /// database that are marked as not yet [TaskStatus.complete] will be set to
  /// [TaskStatus.complete] if the target file for that task exists.
  /// They will also emit [TaskStatus.complete] and [progressComplete] to
  /// their registered listener or callback.
  /// This is a convenient way to capture downloads that have completed while
  /// the app was suspended: on app startup, immediately register your
  /// listener or callbacks, and call [trackTasks] for each group.
  ///
  /// Returns the [FileDownloader] for easy chaining
  Future<FileDownloader> trackTasksInGroup(String group,
      {bool markDownloadedComplete = true}) async {
    await _downloader.trackTasks(group, markDownloadedComplete);
    return this;
  }

  /// Activate tracking for all tasks
  ///
  /// All subsequent tasks will be recorded in persistent storage.
  /// Use the [FileDownloader.database] to get or remove [TaskRecord] objects,
  /// which contain a [Task], its [TaskStatus] and a [double] for progress.
  ///
  /// If [markDownloadedComplete] is true (default) then all tasks in the
  /// database that are marked as not yet [TaskStatus.complete] will be set to
  /// [TaskStatus.complete] if the target file for that task exists.
  /// They will also emit [TaskStatus.complete] and [progressComplete] to
  /// their registered listener or callback.
  /// This is a convenient way to capture downloads that have completed while
  /// the app was suspended: on app startup, immediately register your
  /// listener or callbacks, and call [trackTasks].
  ///
  /// Returns the [FileDownloader] for easy chaining
  Future<FileDownloader> trackTasks(
      {bool markDownloadedComplete = true}) async {
    await _downloader.trackTasks(null, markDownloadedComplete);
    return this;
  }

  /// Wakes up the FileDownloader from possible background state, triggering
  /// a stream of updates that may have been processed while in the background,
  /// and have not yet reached the callbacks or listener
  ///
  /// Calling this method multiple times has no effect.
  Future<void> resumeFromBackground() =>
      _downloader.retrieveLocallyStoredData();

  /// Reschedules tasks that are present in the database but missing from
  /// the native task queue. Typically called on app start, 5s after establishing
  /// the updates listener, calling [trackTasks] or [trackTasksInGroup] and
  /// calling [resumeFromBackground].
  ///
  /// This function retrieves all tasks from the database that are in enqueued
  /// or running states and compares these tasks with the
  /// list of tasks currently present in the native task queue, and all tasks
  /// that are in waitingToRetry state that are not actually waiting to retry
  /// in the downloader
  ///
  /// For each task found only in the database (or waitingToRetry yet not
  /// actually waiting), the function:
  /// 1. Deletes the corresponding record from the database.
  /// 2. Enqueues the task back into the native task queue.
  ///
  /// Finally, the function returns two lists of Tasks in a record. The first
  /// item is the list of successfully re-enqueued tasks, the second item
  /// is the list of tasks that failed to enqueue.
  ///
  /// Throws assertion error if you are not currently tracking tasks, as that
  /// makes this function a no-op that always returns empty lists.
  Future<(List<Task>, List<Task>)> rescheduleKilledTasks() async {
    assert(
        _downloader.isTrackingTasks,
        'rescheduleKilledTasks should only be called if you are tracking tasks. '
        'Did you call trackTasks or trackTasksInGroup?');
    final missingTasks = <Task>{};
    final databaseTasks = await database.allRecords();
    // find missing enqueued/running tasks
    final enqueuedOrRunningDatabaseTasks = databaseTasks
        .where((record) => const [
              TaskStatus.enqueued,
              TaskStatus.running,
            ].contains(record.status))
        .map((record) => record.task)
        .toSet();
    final nativeTasks =
        Set<Task>.from(await FileDownloader().allTasks(allGroups: true));
    missingTasks.addAll(enqueuedOrRunningDatabaseTasks.difference(nativeTasks));
    // find missing tasks waiting to retry
    missingTasks.addAll(databaseTasks
        .where((record) =>
            record.status == TaskStatus.waitingToRetry &&
            !_downloader.tasksWaitingToRetry.contains(record.task))
        .map((record) => record.task));
    final successfullyEnqueued = <Task>[];
    final failedToEnqueue = <Task>[];
    for (final task in missingTasks) {
      await database.deleteRecordWithId(task.taskId);
      if (await FileDownloader().enqueue(task)) {
        successfullyEnqueued.add(task);
      } else {
        failedToEnqueue.add(task);
      }
    }
    return (successfullyEnqueued, failedToEnqueue);
  }

  /// Returns true if task can be resumed on pause
  ///
  /// This future only completes once the task is running and has received
  /// information from the server to determine whether resume is possible, or
  /// if the task fails and resume is possible
  Future<bool> taskCanResume(Task task) => _downloader.taskCanResume(task);

  /// Pause the task
  ///
  /// Returns true if the pause was attempted successfully. Test the task's
  /// status to see if it was executed successfully [TaskStatus.paused] or if
  /// it failed after all [TaskStatus.failed]
  ///
  /// If the [Task.allowPause] field is set to false (default) or if this is
  /// a POST request, this method returns false immediately.
  Future<bool> pause(DownloadTask task) async {
    if (task.allowPause && task.post == null) {
      return _downloader.pause(task);
    }
    return false;
  }

  /// Pauses all tasks, or those in [tasks], or all tasks in group [group]
  ///
  /// Returns list of tasks that were paused
  Future<List<DownloadTask>> pauseAll(
          {Iterable<DownloadTask>? tasks, String? group}) =>
      _downloader.pauseAll(tasks: tasks, group: group);

  /// Resume the task
  ///
  /// If no resume data is available for this task, the call to [resume]
  /// will return false and the task is not resumed.
  /// If resume data is available, the call to [resume] will return true,
  /// but this does not guarantee that resuming is actually possible, just that
  /// the task is now enqueued for resume.
  /// If the task is able to resume, it will, otherwise it will restart the
  /// task from scratch, or fail.
  Future<bool> resume(DownloadTask task) => _downloader.resume(task);

  /// Resume all paused tasks, or those in [tasks], or paused tasks in
  /// group [group]
  ///
  /// Calls to resume will be spaced out over time by [interval], defaults to 50ms
  Future<List<Task>> resumeAll(
      {Iterable<DownloadTask>? tasks,
      String? group,
      Duration interval = const Duration(milliseconds: 50)}) async {
    final results = <Task>[];
    final tasksToResume = switch ((tasks, group)) {
      (Iterable<DownloadTask> tasks, null) => tasks,
      (null, String group) => (await _downloader.getPausedTasks())
          .whereType<DownloadTask>()
          .where((task) => task.group == group),
      (null, null) =>
        (await _downloader.getPausedTasks()).whereType<DownloadTask>(),
      _ => throw AssertionError(
          "Either 'tasks' or 'group' must be provided, or neither, but not both.")
    };
    for (final task in tasksToResume) {
      if (await resume(task)) {
        results.add(task);
      }
      await Future.delayed(interval);
    }
    return results;
  }

  /// Set WiFi requirement globally, based on [requirement].
  ///
  /// Affects future tasks and reschedules enqueued, inactive tasks
  /// with the new setting.
  /// Reschedules running tasks if [rescheduleRunningTasks] is true,
  /// otherwise leaves those running with their prior setting
  Future<bool> requireWiFi(RequireWiFi requirement,
          {final rescheduleRunningTasks = true}) =>
      _downloader.requireWiFi(requirement, rescheduleRunningTasks);

  /// Returns the current global setting for requiring WiFi
  Future<RequireWiFi> getRequireWiFiSetting() =>
      _downloader.getRequireWiFiSetting();

  /// Configure notification for a single task
  ///
  /// The configuration determines what notifications are shown,
  /// whether a progress bar is shown (Android only), and whether tapping
  /// the 'complete' notification opens the downloaded file.
  ///
  /// [running] is the notification used while the task is in progress
  /// [complete] is the notification used when the task completed
  /// [error] is the notification used when something went wrong,
  /// including failed and notFound status
  /// [paused] is the notification shown when the task is paused
  /// [canceled] is the notification shown when the task is canceled programmatically
  ///    or by the user via a notification button
  /// [progressBar] if set will show a progress bar
  /// [tapOpensFile] if set will attempt to open the file when the [complete]
  ///     notification is tapped
  /// [groupNotificationId] if set will group all notifications with the same
  ///    [groupNotificationId] and change the progress bar to number of finished
  ///    tasks versus total number of tasks in the [groupNotificationId].
  ///    Use {numFinished} and {numTotal} tokens in the [TaskNotification.title]
  ///    and [TaskNotification.body] to substitute. Task-specific substitutions
  ///    such as {filename} are not valid when using [groupNotificationId].
  ///    The [groupNotificationId] is considered [complete] when there are no
  ///    more tasks running within that group, and at that point the
  ///    [complete] notification is shown (if configured). If any task in the
  ///    [groupNotificationId] fails, the [error] notification is shown.
  ///    The first character of the [groupNotificationId] cannot be '*'.
  ///
  /// The [TaskNotification] is the actual notification shown for a [Task], and
  /// [body] and [title] may contain special strings to substitute display values:
  /// {filename} to insert the [Task.filename]
  /// {metaData} to insert the [Task.metaData]
  /// {displayName} to insert the [Task.displayName]
  /// {progress} to insert progress in %
  /// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A
  /// {timeRemaining} to insert the estimated time remaining to complete the task
  ///   in HH:MM:SS or MM:SS or --:-- if N/A
  /// {numFinished} to insert the number of finished tasks in a groupNotification
  /// {numFailed} to insert the number of failed tasks in a groupNotification
  /// {numTotal} to insert the number of tasks in a groupNotification
  ///
  /// Actual appearance of notification is dependent on the platform, e.g.
  /// on iOS {progress} is not available and ignored (except for groupNotifications)
  ///
  /// Returns the [FileDownloader] for easy chaining
  FileDownloader configureNotificationForTask(Task task,
      {TaskNotification? running,
      TaskNotification? complete,
      TaskNotification? error,
      TaskNotification? paused,
      TaskNotification? canceled,
      bool progressBar = false,
      bool tapOpensFile = false,
      String groupNotificationId = ''}) {
    _addOrUpdateTaskNotificationConfig(TaskNotificationConfig(
        taskOrGroup: task,
        running: running,
        complete: complete,
        error: error,
        paused: paused,
        canceled: canceled,
        progressBar: progressBar,
        tapOpensFile: tapOpensFile,
        groupNotificationId: groupNotificationId));
    return this;
  }

  /// Configure notification for a group of tasks
  ///
  /// The configuration determines what notifications are shown,
  /// whether a progress bar is shown (Android only), and whether tapping
  /// the 'complete' notification opens the downloaded file.
  ///
  /// [running] is the notification used while the task is in progress
  /// [complete] is the notification used when the task completed
  /// [error] is the notification used when something went wrong,
  /// including failed and notFound status
  /// [paused] is the notification shown when the task is paused
  /// [canceled] is the notification shown when the task is canceled programmatically
  ///    or by the user via a notification button
  /// [progressBar] if set will show a progress bar
  /// [tapOpensFile] if set will attempt to open the file when the [complete]
  ///     notification is tapped
  /// [groupNotificationId] if set will group all notifications with the same
  ///    [groupNotificationId] and change the progress bar to number of finished
  ///    tasks versus total number of tasks in the [groupNotificationId].
  ///    Use {numFinished} and {numTotal} tokens in the [TaskNotification.title]
  ///    and [TaskNotification.body] to substitute. Task-specific substitutions
  ///    such as {filename} are not valid when using [groupNotificationId].
  ///    The [groupNotificationId] is considered [complete] when there are no
  ///    more tasks running within that group, and at that point the
  ///    [complete] notification is shown (if configured). If any task in the
  ///    [groupNotificationId] fails, the [error] notification is shown.
  ///    The first character of the [groupNotificationId] cannot be '*'.
  ///
  /// The [TaskNotification] is the actual notification shown for a [Task], and
  /// [body] and [title] may contain special strings to substitute display values:
  /// {filename} to insert the [Task.filename]
  /// {metaData} to insert the [Task.metaData]
  /// {displayName} to insert the [Task.displayName]
  /// {progress} to insert progress in %
  /// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A
  /// {timeRemaining} to insert the estimated time remaining to complete the task
  ///   in HH:MM:SS or MM:SS or --:-- if N/A
  /// {numFinished} to insert the number of finished tasks in a groupNotification
  /// {numFailed} to insert the number of failed tasks in a groupNotification
  /// {numTotal} to insert the number of tasks in a groupNotification
  ///
  /// Actual appearance of notification is dependent on the platform, e.g.
  /// on iOS {progress} is not available and ignored (except for groupNotifications)
  ///
  /// Returns the [FileDownloader] for easy chaining
  FileDownloader configureNotificationForGroup(String group,
      {TaskNotification? running,
      TaskNotification? complete,
      TaskNotification? error,
      TaskNotification? paused,
      TaskNotification? canceled,
      bool progressBar = false,
      bool tapOpensFile = false,
      String groupNotificationId = ''}) {
    _addOrUpdateTaskNotificationConfig(TaskNotificationConfig(
        taskOrGroup: group,
        running: running,
        complete: complete,
        error: error,
        paused: paused,
        canceled: canceled,
        progressBar: progressBar,
        tapOpensFile: tapOpensFile,
        groupNotificationId: groupNotificationId));
    return this;
  }

  /// Configure default task notification
  ///
  /// The configuration determines what notifications are shown,
  /// whether a progress bar is shown (Android only), and whether tapping
  /// the 'complete' notification opens the downloaded file.
  ///
  /// [running] is the notification used while the task is in progress
  /// [complete] is the notification used when the task completed
  /// [error] is the notification used when something went wrong,
  /// including failed and notFound status
  /// [paused] is the notification shown when the task is paused
  /// [canceled] is the notification shown when the task is canceled programmatically
  ///    or by the user via a notification button
  /// [progressBar] if set will show a progress bar
  /// [tapOpensFile] if set will attempt to open the file when the [complete]
  ///     notification is tapped
  /// [groupNotificationId] if set will group all notifications with the same
  ///    [groupNotificationId] and change the progress bar to number of finished
  ///    tasks versus total number of tasks in the [groupNotificationId].
  ///    Use {numFinished} and {numTotal} tokens in the [TaskNotification.title]
  ///    and [TaskNotification.body] to substitute. Task-specific substitutions
  ///    such as {filename} are not valid when using [groupNotificationId].
  ///    The [groupNotificationId] is considered [complete] when there are no
  ///    more tasks running within that group, and at that point the
  ///    [complete] notification is shown (if configured). If any task in the
  ///    [groupNotificationId] fails, the [error] notification is shown.
  ///    The first character of the [groupNotificationId] cannot be '*'.
  ///
  /// The [TaskNotification] is the actual notification shown for a [Task], and
  /// [body] and [title] may contain special strings to substitute display values:
  /// {filename} to insert the [Task.filename]
  /// {metaData} to insert the [Task.metaData]
  /// {displayName} to insert the [Task.displayName]
  /// {progress} to insert progress in %
  /// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A
  /// {timeRemaining} to insert the estimated time remaining to complete the task
  ///   in HH:MM:SS or MM:SS or --:-- if N/A
  /// {numFinished} to insert the number of finished tasks in a groupNotification
  /// {numFailed} to insert the number of failed tasks in a groupNotification
  /// {numTotal} to insert the number of tasks in a groupNotification
  ///
  /// Actual appearance of notification is dependent on the platform, e.g.
  /// on iOS {progress} is not available and ignored (except for groupNotifications)
  ///
  /// Returns the [FileDownloader] for easy chaining
  FileDownloader configureNotification(
      {TaskNotification? running,
      TaskNotification? complete,
      TaskNotification? error,
      TaskNotification? paused,
      TaskNotification? canceled,
      bool progressBar = false,
      bool tapOpensFile = false,
      String groupNotificationId = ''}) {
    _addOrUpdateTaskNotificationConfig(TaskNotificationConfig(
        taskOrGroup: null,
        running: running,
        complete: complete,
        error: error,
        paused: paused,
        canceled: canceled,
        progressBar: progressBar,
        tapOpensFile: tapOpensFile,
        groupNotificationId: groupNotificationId));
    return this;
  }

  /// Helper to add or update a task notification config
  void _addOrUpdateTaskNotificationConfig(
      TaskNotificationConfig taskNotificationConfig) {
    _downloader.notificationConfigs.remove(taskNotificationConfig);
    _downloader.notificationConfigs.add(taskNotificationConfig);
  }

  /// Perform a server request for this [request]
  ///
  /// A server request returns an [http.Response] object that includes
  /// the [body] as String, the [bodyBytes] as [UInt8List] and the [json]
  /// representation if available.
  /// It also contains the [statusCode] and [reasonPhrase] that may indicate
  /// an error, and several other fields that may be useful.
  /// A local error (e.g. a SocketException) will yield [statusCode] 499, with
  /// details in the [reasonPhrase]
  ///
  /// The request will abide by the [retries] set on the [request], and set
  /// [headers] included in the [request]
  ///
  /// The [http.Client] object used for this request is the [httpClient] field of
  /// the downloader. If not set, the default [http.Client] will be used.
  /// The request is executed on an Isolate, to ensure minimal interference
  /// with the main Isolate
  Future<http.Response> request(Request request) => compute(_doRequest, (
        request,
        DesktopDownloader.requestTimeout,
        DesktopDownloader.proxy,
        DesktopDownloader.bypassTLSCertificateValidation
      ));

  /// Move the file represented by the [task] to a shared storage
  /// [destination] and potentially a [directory] within that destination. If
  /// the [mimeType] is not provided we will attempt to derive it from the
  /// [Task.filePath] extension
  ///
  /// Returns the path to the stored file, or null if not successful.
  ///
  /// NOTE: on iOS, using [destination] [SharedStorage.images] or
  /// [SharedStorage.video] adds the photo or video file to the Photos
  /// library. This requires the user to grant permission, and requires the
  /// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The
  /// returned value is NOT a filePath but an identifier. If the full filepath
  /// is required, follow the [moveToSharedStorage] call with a call to
  /// [pathInSharedStorage], passing the identifier obtained from the call
  /// to [moveToSharedStorage] as the filePath parameter. This requires the user to
  /// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription"
  /// key to be set in Info.plist. The returned value is the actual file path
  /// of the photo or video in the Photos Library.
  ///
  /// Platform-dependent, not consistent across all platforms
  Future<String?> moveToSharedStorage(
          DownloadTask task, SharedStorage destination,
          {String directory = '', String? mimeType}) async =>
      moveFileToSharedStorage(await task.filePath(), destination,
          directory: directory, mimeType: mimeType);

  /// Move the file represented by [filePath] to a shared storage
  /// [destination] and potentially a [directory] within that destination. If
  /// the [mimeType] is not provided we will attempt to derive it from the
  /// [filePath] extension
  ///
  /// Returns the path to the stored file, or null if not successful
  ///
  /// NOTE: on iOS, using [destination] [SharedStorage.images] or
  /// [SharedStorage.video] adds the photo or video file to the Photos
  /// library. This requires the user to grant permission, and requires the
  /// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The
  /// returned value is NOT a filePath but an identifier. If the full filepath
  /// is required, follow the [moveToSharedStorage] call with a call to
  /// [pathInSharedStorage], passing the identifier obtained from the call
  /// to [moveToSharedStorage] as the filePath parameter. This requires the user to
  /// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription"
  /// key to be set in Info.plist. The returned value is the actual file path
  /// of the photo or video in the Photos Library.
  ///
  /// Platform-dependent, not consistent across all platforms
  Future<String?> moveFileToSharedStorage(
          String filePath, SharedStorage destination,
          {String directory = '', String? mimeType}) async =>
      _downloader.moveToSharedStorage(
          filePath, destination, directory, mimeType);

  /// Returns the filePath to the file represented by [filePath] in shared
  /// storage [destination] and potentially a [directory] within that
  /// destination.
  ///
  /// Returns the path to the stored file, or null if not successful
  ///
  /// See the documentation for [moveToSharedStorage] for special use case
  /// on iOS for .images and .video
  ///
  /// Platform-dependent, not consistent across all platforms
  Future<String?> pathInSharedStorage(
          String filePath, SharedStorage destination,
          {String directory = ''}) async =>
      _downloader.pathInSharedStorage(filePath, destination, directory);

  /// Open the file represented by [task] or [filePath] using the application
  /// available on the platform.
  ///
  /// [mimeType] may override the mimetype derived from the file extension,
  /// though implementation depends on the platform and may not always work.
  ///
  /// Returns true if an application was launched successfully
  Future<bool> openFile({Task? task, String? filePath, String? mimeType}) {
    assert(task != null || filePath != null, 'Task or filePath must be set');
    assert(!(task != null && filePath != null),
        'Either task or filePath must be set, not both');
    return _downloader.openFile(task, filePath, mimeType);
  }

  /// Return the platform version as a String
  ///
  /// On Android this is the API integer, e.g. "33"
  /// On iOS this is the iOS version, e.g. "16.1"
  /// On desktop this is a description of the OS version, not parsable
  Future<String> platformVersion() => _downloader.platformVersion();

  /// Closes the [updates] stream and re-initializes the [StreamController]
  /// such that the stream can be listened to again
  Future<void> resetUpdates() => _downloader.resetUpdatesStreamController();

  /// Destroy the [FileDownloader]. Subsequent use requires initialization
  void destroy() {
    _downloader.destroy();
    Localstore.instance.clearCache();
  }
}

/// Performs the actual server request, with retries
///
/// This function is run on an Isolate to ensure performance on the main
/// Isolate is not affected
Future<http.Response> _doRequest(
    (Request, Duration?, Map<String, dynamic>, bool) params) async {
  final (request, requestTimeout, proxy, bypassTLSCertificateValidation) =
      params;
  Logger.root.level = Level.ALL;
  Logger.root.onRecord.listen((LogRecord rec) {
    if (kDebugMode) {
      print('${rec.loggerName}>${rec.level.name}: ${rec.time}: ${rec.message}');
    }
  });
  final log = Logger('FileDownloader.request');
  DesktopDownloader.setHttpClient(
      requestTimeout, proxy, bypassTLSCertificateValidation);
  final client = DesktopDownloader.httpClient;
  var response = http.Response('', 499,
      reasonPhrase: 'Not attempted'); // dummy to start with
  while (request.retriesRemaining >= 0) {
    try {
      response = await switch (request.httpRequestMethod) {
        'GET' => client.get(Uri.parse(request.url), headers: request.headers),
        'POST' => client.post(Uri.parse(request.url),
            headers: request.headers, body: request.post),
        'HEAD' => client.head(Uri.parse(request.url), headers: request.headers),
        'PUT' => client.put(Uri.parse(request.url),
            headers: request.headers, body: request.post),
        'DELETE' => client.delete(Uri.parse(request.url),
            headers: request.headers, body: request.post),
        'PATCH' => client.patch(Uri.parse(request.url),
            headers: request.headers, body: request.post),
        _ => Future.value(response)
      };
      if ([200, 201, 202, 203, 204, 205, 206, 404]
          .contains(response.statusCode)) {
        return response;
      }
    } catch (e) {
      log.warning(e);
      response = http.Response('', 499, reasonPhrase: e.toString());
    }
    // error, retry if allowed
    request.decreaseRetriesRemaining();
    if (request.retriesRemaining < 0) {
      return response; // final response with error
    }
    final waitTime = Duration(
        seconds: pow(2, (request.retries - request.retriesRemaining)).toInt());
    await Future.delayed(waitTime);
  }
  throw ArgumentError('Request to ${request.url} had no retries remaining');
}
